feat(enterprise-import): reconcile de emails em sessão persistida - #19
feat(enterprise-import): reconcile de emails em sessão persistida#19daniloleonecarneiro wants to merge 7 commits into
Conversation
a4cc4bc to
f9fa3ab
Compare
… match por created_at Reescreve o fluxo super-admin de conciliação de emails mascarados (export Enterprise) para operar em lotes sobre uma sessão server-side, viável para bases reais (350k contatos, CSV 20MB, dezenas de milhares de colisões de máscara): - Sessão persistida (email_reconcile_sessions/_items): CSV parseado e matched UMA vez; depois tudo é incremental — apply-auto em chunks, fila de ambíguos paginada com busca, resolve em lote (best-name / skip-remaining) e progresso derivado dos status dos itens. - Match em camadas: created_at como sinal forte (tier minuto exato tolerando offset fixo de timezone, tier mesma data) antes do desempate por similaridade de nome (Jaccard, pontuação/ordem insensível); import preserva o created_at da origem, então o par (máscara, created_at) funciona como chave natural. - Colunas obrigatórias (email, created_at e sinal de nome — coluna name OU par first_name/last_name) validadas nos headers antes de processar; formulário de upload trava as obrigatórias e permite ignorar opcionais. - Email tratado como chave única por conta em todo o fluxo: dedup de auto-picks disputando a mesma linha, pré-checagem amigável no apply (em vez do erro cru do índice único), candidatos já usados desabilitados na UI e estratégias automáticas nunca consomem email já aplicado (item permanece pendente para decisão manual). - Tabela "itens da sessão" (quem casou com o quê) com busca por nome/email e filtros de tipo/status; badges de concordância de data nos candidatos; toggle de seleção nos ambíguos. - Cadeia de limites de upload alinhada (nginx 64m/300s no /api, body-parser 64mb escopado em /imports, Joi 50MB) para o CSV viajar embutido no JSON.
…scarar As rotas de leitura (detalhe por id/uuid, listagem paginada e findAll) reescreviam o email com maskEmail() na resposta. Como a máscara do email real é idêntica ao placeholder importado do Enterprise, contatos reconciliados pareciam continuar mascarados — e consumidores da API não tinham como obter o endereço utilizável para o fluxo de envios. Agora o email retorna como está no banco: reconciliado = endereço real; pendente de reconcile = placeholder (que é o valor armazenado mesmo). O campo derivado maskedEmail segue disponível no payload e a lista de supressões permanece mascarada.
f9fa3ab to
fd0e2c6
Compare
filipecrosk
left a comment
There was a problem hiding this comment.
Review: not ready to merge
The PR is structurally mergeable, but I found several correctness and integration blockers.
Blocking correctness issues
-
Timezone matching does not implement a fixed offset. The parser accepts any half-hour offset independently for each candidate, so unrelated timestamps can receive the strongest match tier. It needs to infer/use one consistent offset, or otherwise avoid treating arbitrary offsets as exact matches.
-
Automatic conflicts are marked as failed instead of remaining pending. When
applyAutoChunk()finds that the selected email already belongs to another contact, the item is written asfailed. The PR description says these items should remain available for manual resolution. -
Email uniqueness deduplication is by CSV row, not normalized email. Duplicate CSV rows with the same email can produce multiple automatic winners. Account-wide uniqueness must be enforced on the normalized email before applying.
-
The production CSV contract is incompatible with the new parser. Our production
/contacts/export-streamcurrently serializes PostgreSQLDatevalues throughfast-csvas locale strings such asThu Jul 09 2026 03:48:16 GMT-0400 (...). This parser accepts ISO/SQL and Brazilian formats only, socreated_atbecomes null and matching silently falls back to name matching. We should change the production exporter to emit ISO timestamps (and add a regression test), or the PR must explicitly support the current format. -
Raw email exposure changes the permission boundary. The second commit makes ordinary contact list/detail responses return stored raw emails under
contacts_view, while exports remain protected by the strongercontacts_exportpermission. This needs explicit security/product approval, or should use a narrowly scoped privileged endpoint. -
Session replacement is non-atomic. The old session is deleted before the new header/items are fully inserted. An interrupted request can leave a partial session that appears valid.
Standards / completeness
- No tests cover the persisted session service, controller endpoints, UI workflow, or the raw-email permission change; the added 26 tests mainly cover the matching service.
- The new REST API and response-contract changes are not documented.
- GitHub reports no checks and no reviews for this PR.
Validation
I ran the exact head locally:
- Backend build/type-check: passed
- Frontend build/type-check: passed
- Backend: 59 suites / 795 tests passed
- Frontend: 187 files / 1,781 tests passed
- Focused reconciliation suite: 26 tests passed
Please address the correctness and permission issues, stabilize the production CSV timestamp format (or update the parser), add endpoint/service/UI tests and API documentation, and enable/require CI checks before merging.
O export em stream escrevia o Date cru do driver no fast-csv, que o
serializava com Date.prototype.toString() — string dependente de locale e
host ("Thu Jul 09 2026 03:48:16 GMT-0300 (...)") que nenhum consumidor
parseia de forma confiável. O reconcile de emails, que casa contatos por
created_at, lia esse campo como nulo e caía silenciosamente no match por
nome.
O contrato agora é ISO-8601 em UTC, isolado em toIsoTimestamp() com teste
de regressão (inclusive de estabilidade entre timezones do host).
…de locale O nível de concordância exata aceitava qualquer offset alinhado a meia hora dentro de ±14h, avaliado por candidato: cerca de 57 offsets eram plausíveis, então linhas sem relação nenhuma alcançavam o tier mais forte e o created_at deixava de desempatar colisão de máscara. Agora o offset é UM só por CSV, inferido pela moda dos deltas de pares confiáveis (inferCsvOffsetMinutes) e exigido igual para o tier exato. Amostra pequena demais ou sem dominância não vira palpite: o match para no nível de data, que é a falha segura (decisão do operador). O parser também passa a ler o formato Date.prototype.toString() do export antigo — CSVs nesse formato ainda estão na mão do operador e eram descartados como nulos.
…email O offset do CSV passa a ser inferido uma vez por execução, a partir dos pares em que a máscara colide 1:1 dos dois lados — ali o pareamento é certo, então o delta entre os dois timestamps É o offset do export. Máscaras em colisão ficam de fora da amostra de propósito: alimentar a inferência com palpite anula o propósito dela. A arbitragem de picks automáticos passa a ser por email normalizado e não por número da linha. Exports repetem o mesmo endereço em várias linhas, e chavear por linha deixava cada duplicata gerar seu próprio vencedor automático — o segundo morria depois no índice de unicidade. O banco garante por email; é por email que se arbitra.
… falha Item cujo endereço já pertence a outro contato era marcado como failed e saía do fluxo — não havia nada quebrado ali, havia uma decisão a tomar, e o operador ficava sem ela. Passa a existir o status conflict: terminal para as passadas automáticas (repetir daria o mesmo resultado), mas resolvível — o item aparece na fila de revisão, entra no resolve, é varrido pelo skip-remaining e pode ser reaberto em lote depois que o operador libera os endereços (POST session/reopen-conflicts). Picks automáticos já nascem em conflito quando o endereço está ocupado, em vez de gastar uma tentativa de apply para descobrir. A busca de donos de endereço passa a ser em blocos — a lista IN recebia lotes inteiros (20k) ou a sessão toda (350k em imports reais). A substituição da sessão também vira transação única: a rewrite interrompida deixava cabeçalho com itens parciais, que se lê como uma conciliação completa e silenciosamente truncada. Inclui specs do serviço de sessão e do controller (ambos sem cobertura até aqui), Swagger nos endpoints da sessão e o painel de conflitos na UI.
Devolver o endereço armazenado nas rotas de leitura deixou contacts_view disclosando o mesmo dado que o export protege com contacts_export — duas fronteiras diferentes para a mesma informação. A leitura crua passa a responder à permissão de export (ou super admin); quem tem só contacts_view volta a receber a máscara. Na prática quem opera import/reconcile (super admin, admin) segue vendo o endereço real, e support/analyst não. Sem contexto de principal (job interno) o padrão é mascarar. A regra fica isolada em email-visibility.util com specs próprios — a suíte de contacts.service não compila fora do docker.
Summary
Reescreve o fluxo super-admin de conciliação de emails mascarados (import Enterprise) para operar em lotes sobre uma sessão server-side, viável para exports grandes (centenas de milhares de contatos, com milhares de colisões de máscara):
email_reconcile_sessions/_items, migration1783000000000): CSV parseado e matched uma vez; depois tudo é incremental — apply-auto em chunks, fila de ambíguos paginada, resolve em lote (best-name / skip-remaining), progresso derivado dos status. A substituição da sessão é transacional: rewrite interrompida não deixa cabeçalho com itens parciais.created_at: o import preserva o timestamp da origem e o export CSV carrega o mesmo campo, então (máscara, created_at) age como chave natural. Tier de minuto exato > mesma data > desempate por similaridade de nome (Jaccard, insensível a pontuação/ordem).created_ataceitos: ISO/SQL,DD/MM/YYYYe oDate.prototype.toString()que o export em stream emitia (Thu Jul 09 2026 03:48:16 GMT-0300 (...)). O exportador passou a escrever ISO-8601 UTC, mas CSVs baixados antes disso continuam funcionando em vez de viraremcreated_atnulo — que degradava o match para nome, silenciosamente.email,created_ate sinal de nome — colunanameou parfirst_name/last_name); formulário de upload trava as obrigatórias e permite ignorar opcionais.conflict— terminal para as passadas automáticas (repetir daria o mesmo resultado, e deixar pendente faria o cliente rodar em loop), mas resolvível: o item aparece na fila de revisão, entra noresolve, é varrido peloskip-remaininge pode ser reaberto em lote (POST session/reopen-conflicts) depois que o operador libera os endereços. Picks automáticos com endereço ocupado já nascem em conflito, sem gastar uma tentativa de apply para descobrir.conflict); busca também na fila de ambíguos; badges de concordância de data; painel de conflitos com ação de reabertura.client_max_body_size 64m+proxy_read_timeout 300sno/api, body-parser 64mb escopado em/imports, Joi 50MB.audience:contacts_export(ou super admin) — a mesma fronteira que já protege o export do mesmo dado. Sócontacts_viewcontinua vendo a máscara, assim como chamadas sem contexto de principal. Supressões seguem mascaradas.Test plan
reconcile-timestamp.util.spec.ts(13): formatos aceitos/rejeitados, inferência do offset (moda, amostra insuficiente, sem dominância, timestamps que já trazem offset) e os níveis de concordância sob cada caso.email-reconcile.service.spec.ts(31): colunas obrigatórias (ambas as formas de nome), desambiguação por created_at, offset inferido vs ausente, CSV parcial vs colisões de máscara, arbitragem de endereço duplicado e o apply legado.email-reconcile-session.service.spec.ts(18): criação transacional da sessão, conflito pré-marcado, apply-auto (aplicado/conflito/falha e término do loop), resolve alcançando itens em conflito, bulk best-name não consumindo endereço ocupado, reabertura e contadores.enterprise-import.controller.spec.ts(9): contrato dos endpoints da sessão, clamps de paginação e filtros ignorados quando desconhecidos.export-timestamp.util.spec.ts(4) eemail-visibility.util.spec.ts(8): regressão do contrato ISO do export e da fronteira de permissão do email cru.reconcile-gateway.test.ts(8, frontend): rotas, ausência de timeout no upload, filtro de conflito e reabertura.tsc --noEmitlimpo em backend e frontend; locales pt-BR/en-US/es-ES válidos.hashed_email(listener@BeforeUpdate), auditoria registrada, busca e tabela de itens exercitadas contra a sessão viva.Notas
@ApiOperation/@ApiQuery).docker-build.yml; não há workflow de teste para rodar as suítes acima no CI. Fica como item de infra em PR próprio.